跳到主要内容

i18n 国际化实现

Java 国际化实现起来也比较简单,实际上就是读取配置文件中的数据而已,与普通的文件读取区别就是多了动态确定配置文件名。

关于 Java 实现国际化 Oracle 官网也有专门的教程:https://docs.oracle.com/javase/tutorial/i18n/index.html

这个教程很清晰,基本上看一遍就空白原理了,不过本文还是要赘述一下。

Java 实现国际化主要使用 java.util. ResourceBundle 类。该工具类主要是用于加载 resources 下的 Resource Bundle(就是我们配置的语言文件),根据 java.util. Locale 来进行国际化转化,所以在之前需要先介绍下 java.util. Locale。

java.util. Locale

Locale 类是 Java 内置的本地方言类,本身内置了多个国家方言,这些内置的基本上就够我们使用了。通过该类我们可以设置和获取指定方言,这个内置的国家方言为后面 Resource Bundle 的定义提供了标准。

看下基本示例:

public class I18nMain {

public static void main(String[] args) {

// 获取系统默认方言(默认方言获取到的是系统语言, 当前操作系统默认方言是 en_CN)
Locale systemDefaultLocal = Locale.getDefault();
System.out.println(systemDefaultLocal);

// 设置系统默认方言
// 除了获取系统默认方言外, 我们也可以明确设置系统默认方言. 比如 zh_CN(简体中文)
Locale.setDefault(Locale.SIMPLIFIED_CHINESE);

// 再次获取默认系统方言
// 就会发现由 en_CN 变成 zh_CN 了
System.out.println(Locale.getDefault());

// 通过指定语言和地区构造方言实例, 如下 zh_TW(繁体中文):
// 等效于 Locale.TRADITIONAL_CHINESE
Locale createLocal = new Locale("zh", "TW");

System.out.println(createLocal);
}
}

set-locale-a04a0edf5f1173ba.png

另外,Locale 类中内置了许多方言实例(如下图),这些方言实例基本上就够我们使用了。如果业务太广不够用也可以自己构造 Locale 实例~

locale-4d306b99fe9d1faf.png

用法很简单,不过我们需要知道关于方言(java.util. Locale)的组成包括两部分:语言和区域。

注意上面输出的方言:zh_CN、en_CN、zh_TW,这是标准的方言格式。_ 前面的表示具体的国家、后面的表示的是语言。比如我们上面构造的实例:

Locale createLocal = new Locale("zh", "TW");

前面也说了,Locale 就是为 java.util. ResourceBundle 提供区域标准。这个所谓的标准就是读取指定的语言文件,比如上面构造的 Locale 输出信息是 zh_TW,那么 java.util. ResourceBundle 就会读取资源目录下的 zh_TW 文件。所谓的本地或就是通过这种方式来确定要读取的语言文件。

java.text. MessageFormat

在过去 i18n 消息时我们可能还需要做一些消息格式化定制需求。

简单地说 MessageFormat 就是一个占位符消息填充,与我们使用 slf4j 打印日志时使用的占位符一致,不过我们需要指定下标。看下示例:

public class MessageFormatMain {

public static void main(String[] args) {
MessageFormat format = new MessageFormat("hello, {0}. My name is {1}.");

String message = format.format(new String[]{"Han Meimei", "Lilei"});

System.out.println(message);
}
}

输出如下:

hello, Han Meimei. My name is Lilei.

java.util. ResourceBundle

终于到了我们的主角上场表演了~

ResourceBundle 主要用于加载资源目录(通常是 resources 目录)下的语言包文件,先看下演示示例:

在 resources 目录下创建一个 i18n 目录,之后在目录下创建 Resource Bundle 文件:

create-resource-bundle-file-ecbeaca92756e0a7.png

resource-bundle-add-lang-869faf8570425d43.png

之后就会在 i18n 目录下出现三个语言包文件:

i18n-dir-QnSyO0vvMH4.png

这里我默认将 LanguageBundle 文件设置简体中文语言包,en_US 为英语语言包,zh_TW 设置为繁体中文语言包。

现在就添加语言配置:

i18n-add-item-0y5N5zw.png

国际化示例

public class ResourceBundleMain {

// 语言包所在的目录
// 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
static final String BASE_NAME = "i18n/LanguageBundle";

public static void main(String[] args) {

// 设置默认方言为 简体中文 zh_CN
Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault());
System.out.println(zhCnBundle.getString("greetings"));

// 获取英文方言语言环境
ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US);
System.out.println(usBundle.getString("greetings"));

// 获取繁体中文方言语言环境
ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE);
System.out.println(zhTwBundle.getString("greetings"));
}

}

正常输出如下:

你好
Hello
你號

但是有时候你会发现输出的中文是乱码:

chinese-encoding-TgqYFjnY8aw.png

中文乱码问题

导致中文乱码的原因是由于 properties 文件编码的问题,默认情况下 properties 的编码格式是 ISO8895-1 编码格式,所以当出现中文时就会出现编码问题。

解决方案主要有两种:修改 properties 编码格式,但是这种是治标不治本。

第二种就是扩展源代码,直接修改文件流编码格式,这是推荐的方式。来看下怎么去扩展:

通过 Debug 跟踪源代码你会发现文件流生成是在 java.util. ResourceBundle. Control#newBundle 方法中实现的,Control 是 ResourceBundle 中的内部类。

跟踪的调用链如下:

java.util.ResourceBundle#getBundle(java.lang.String);
java.util.ResourceBundle#getBundleImpl;
java.util.ResourceBundle#findBundle;
java.util.ResourceBundle#loadBundle;
java.util.ResourceBundle.Control#newBundle;

所以我们需要扩展 Control#newBundle 方法。在此之前先来看下为什么会是该方法,一起阅读下该方法:

源码解析编码问题

public ResourceBundle newBundle(String baseName, Locale locale, String format,
ClassLoader loader, boolean reload)
throws IllegalAccessException, InstantiationException, IOException {
String bundleName = toBundleName(baseName, locale);
ResourceBundle bundle = null;
if (format.equals("java.class")) {
try {
@SuppressWarnings("unchecked")
Class<? extends ResourceBundle> bundleClass
= (Class<? extends ResourceBundle>)loader.loadClass(bundleName);

// If the class isn't a ResourceBundle subclass, throw a
// ClassCastException.
if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
bundle = bundleClass.newInstance();
} else {
throw new ClassCastException(bundleClass.getName()
+ " cannot be cast to ResourceBundle");
}
} catch (ClassNotFoundException e) {
}
} else if (format.equals("java.properties")) { // 第一处: 注意这个判断
final String resourceName = toResourceName0(bundleName, "properties");
if (resourceName == null) {
return bundle;
}
final ClassLoader classLoader = loader;
final boolean reloadFlag = reload;
InputStream stream = null;
try {
stream = AccessController.doPrivileged(
new PrivilegedExceptionAction<InputStream>() {
public InputStream run() throws IOException {
InputStream is = null;
if (reloadFlag) {
URL url = classLoader.getResource(resourceName);
if (url != null) {
URLConnection connection = url.openConnection();
if (connection != null) {
// Disable caches to get fresh data for
// reloading.
connection.setUseCaches(false);
is = connection.getInputStream();
}
}
} else {
is = classLoader.getResourceAsStream(resourceName);
}
return is;
}
});
} catch (PrivilegedActionException e) {
throw (IOException) e.getException();
}
if (stream != null) {
try {
// 第二处: 返回流
bundle = new PropertyResourceBundle(stream);
} finally {
stream.close();
}
}
} else {
throw new IllegalArgumentException("unknown format: " + format);
}
return bundle;
}

因为我们的配置文件是 properties,所以会进入 第一处,通过断点你最终会发现会在 第二处 返回流数据。所以我们直接在这里修改流编码即可,其实修改流编码很简单。仅仅需要修改 new PropertyResourceBundle(stream) 这个代码即可。

通过查看 PropertyResourceBundle 源码你会发现他有两个构造方法:

java.util.PropertyResourceBundle#PropertyResourceBundle(java.io.InputStream);
java.util.PropertyResourceBundle#PropertyResourceBundle(java.io.Reader);

在 java.util. ResourceBundle. Control#newBundle 源码中使用的是第一个。

注意第二个构造方法使用的是 java.io.Reader 字符流,Java IO 主要使用的是适配器模式。如果对 JDK 源码比较熟悉的话你会发现 java.io.Reader 有一个子类: java.io.InputStreamReader

该类又有几个构造方法:

java.io.InputStreamReader#InputStreamReader(java.io.InputStream);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.lang.String);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.nio.charset.Charset);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.nio.charset.CharsetDecoder);

其中第二个个第三个构造方法可以设置编码格式,然后你就会发现解决办法有了,直接将方面的 第二处 源码如下即可:

bundle = new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));

是不是很简单?

所以写一个 java.util. ResourceBundle. Control 扩展类,用于设置编码格式:

public class ResourceBundleControlEncode extends ResourceBundle.Control {

private final Charset encode;

public ResourceBundleControlEncode() {
this(StandardCharsets.UTF_8);
}

public ResourceBundleControlEncode(Charset encode) {
this.encode = encode;
}

/**
* copy from {@link ResourceBundle.Control#newBundle(String, Locale, String, ClassLoader, boolean)}
*/
@Override
public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException {
String bundleName = toBundleName(baseName, locale);
ResourceBundle bundle = null;
if ("java.class".equals(format)) {
try {
@SuppressWarnings("unchecked")
Class<? extends ResourceBundle> bundleClass
= (Class<? extends ResourceBundle>) loader.loadClass(bundleName);

// If the class isn't a ResourceBundle subclass, throw a
// ClassCastException.
if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
bundle = bundleClass.newInstance();
} else {
throw new ClassCastException(bundleClass.getName()
+ " cannot be cast to ResourceBundle");
}
} catch (ClassNotFoundException ignored) {
}
} else if ("java.properties".equals(format)) {
final String resourceName = toResourceName0(bundleName, "properties");
if (resourceName == null) {
return null;
}
final ClassLoader classLoader = loader;
final boolean reloadFlag = reload;
InputStream stream;
try {
stream = AccessController.doPrivileged(
(PrivilegedExceptionAction<InputStream>) () -> {
InputStream is = null;
if (reloadFlag) {
URL url = classLoader.getResource(resourceName);
if (url != null) {
URLConnection connection = url.openConnection();
if (connection != null) {
// Disable caches to get fresh data for
// reloading.
connection.setUseCaches(false);
is = connection.getInputStream();
}
}
} else {
is = classLoader.getResourceAsStream(resourceName);
}
return is;
});
} catch (PrivilegedActionException e) {
throw (IOException) e.getException();
}
if (stream != null) {
try {
// 更改流的编码格式, 解决中文编码问题
bundle = new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));
} finally {
stream.close();
}
}
} else {
throw new IllegalArgumentException("unknown format: " + format);
}
return bundle;
}

/**
* copy from {@link ResourceBundle.Control#toResourceName0(String, String)}
*/
private String toResourceName0(String bundleName, String suffix) {
// application protocol check
if (bundleName.contains("://")) {
return null;
} else {
return toResourceName(bundleName, suffix);
}
}
}

扩展类 ResourceBundleControlEncode 提供了两个构造方法:

org.example.jackson.ResourceBundleControlEncode#ResourceBundleControlEncode();
org.example.jackson.ResourceBundleControlEncode#ResourceBundleControlEncode(java.nio.charset.Charset);

默认的构造方法设置的编码格式就是 UTF-8,第二个构造方法用于设置自定义编码。

扩展类已经写好了,该怎么使用呢?

前面获取方言资源文件我们使用的是 java.util. ResourceBundle#getBundle(java.lang. String) 方法,他其实有许多重载方法,其中y有如下重载方法:

java.util.ResourceBundle#getBundle(java.lang.String, java.util.ResourceBundle.Control);
java.util.ResourceBundle#getBundle(java.lang.String, java.util.Locale, java.util.ResourceBundle.Control);

好了,现在知道该怎么使用了,看下最终示例:

public class ResourceBundleMain {

// 语言包所在的目录
// 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
static final String BASE_NAME = "i18n/LanguageBundle";

public static void main(String[] args) {

// 设置默认方言为 简体中文 zh_CN
Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault(), new ResourceBundleControlEncode());
System.out.println(zhCnBundle.getString("greetings"));

// 获取英文方言语言环境
ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US, new ResourceBundleControlEncode());
System.out.println(usBundle.getString("greetings"));

// 获取繁体中文方言语言环境
ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE, new ResourceBundleControlEncode());
System.out.println(zhTwBundle.getString("greetings"));
}

}

输出:

fix-chinese-encoding-b1t3HCAnX9I.png

完美~

Spring 国际化的实现

Spring 实现国际化主要使用的是 org.springframework.context.support. ResourceBundleMessageSource 类。该类接受一个编码属性,所以在 Spring 中我们直接指定编码格式就不会有中文乱码问题了。

那 Spring 是如何实现的呢?

通过阅读 org.springframework.context.support. ResourceBundleMessageSource 源码你会发现在其内部定义了一个内部类: org.springframework.context.support. ResourceBundleMessageSource. MessageSourceControl。

该类扩展了 java.util. ResourceBundle. Control 类:

SpringMessageSourceControl-OBXKunD2ZXc.png

我们直接看他的流式如何处理的:

SpringMessageSourceControlDefaultEncoding-5GuStCWPCX0.png

然后你就会发现与我们处理的类似,如果指定了编码格式就使用我们设置的编码格式,并且同样借助了 java.io. InputStreamReader 字符流类。

好了,最后我们来写一个 Bean 注册示例:

@Configuration
public class I18nConfig {

static final String BASE_NAME = "i18n/LanguageBundle";

@Bean
public ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename(BASE_NAME);
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
}

使用示例就不多说了~